SPB Git

Repository Public

page.tsx 8.1 KB 127 lines tsx

1import type { Metadata } from "next";2import Link from "next/link";3import { notFound } from "next/navigation";4import { FilmActions } from "@/components/FilmActions";5import { FilmCard } from "@/components/FilmCard";6import { HeroPlayer } from "@/components/HeroPlayer";7import { getFilm, relatedFilms } from "@/lib/films";8import { dict, fmtDate, fmtDuration, href, type Locale } from "@/lib/i18n";910type P = { params: Promise<{ locale: string; slug: string }> };11const BASE = process.env.PUBLIC_URL ?? "https://clip.spboucher.ai";1213export async function generateMetadata({ params }: P): Promise<Metadata> {14  const { locale, slug } = await params;15  const film = await getFilm(locale as Locale, slug);16  if (!film) return { title: "Not found", robots: { index: false } };17  const path = `/film/${film.slug}`;18  return {19    title: film.title, description: film.synopsis,20    alternates: { canonical: href(locale as Locale, path), languages: { en: path, fr: `/fr${path}` } },21    openGraph: {22      type: "video.other", title: film.title, description: film.synopsis, url: href(locale as Locale, path),23      images: [{ url: film.poster, width: 720, height: 1280, alt: film.title }],24      videos: [{ url: `${BASE}${film.video}`, type: "video/mp4", width: film.width, height: film.height }],25    },26    twitter: { card: "summary_large_image", title: film.title, description: film.synopsis, images: [film.poster] },27  };28}2930interface Provenance {31  models?: { role: string; provider: string; model: string }[]; pipeline?: string[]; generatedAt?: string; humanApproval?: string;32  manifest?: { audio?: { narration: string; bed: string; music: string }; shots?: unknown[] };33}3435export default async function FilmPage({ params }: P) {36  const { locale: l, slug } = await params;37  const locale = l as Locale;38  const t = dict(locale);39  const film = await getFilm(locale, slug);40  if (!film) notFound();41  const related = await relatedFilms(locale, film);42  const prov = film.provenance as Provenance;43  const jsonLd = {44    "@context": "https://schema.org", "@type": "VideoObject", name: film.title, description: film.synopsis, inLanguage: film.language === "bilingual" ? ["en", "fr"] : film.language,45    thumbnailUrl: `${BASE}${film.poster}`, contentUrl: `${BASE}${film.video}`, uploadDate: film.publishedAt, duration: `PT${Math.round(film.duration)}S`,46    width: film.width, height: film.height, isFamilyFriendly: true, creator: { "@type": "Organization", name: "CLIP", url: BASE },47    creditText: "AI-generated film", ...(film.series ? { isPartOf: { "@type": "CreativeWorkSeries", name: film.series.title } } : {}),48  };49  const facts: [string, string][] = [50    [t.film.runtime, fmtDuration(film.duration)], [t.film.language, t.langs[film.language] ?? film.language], [t.film.released, fmtDate(film.publishedAt, locale)],51    [t.film.kind, t.film[film.kind]],52  ];5354  return (55    <article className="py-6 md:py-10">56      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }} />57      <div className="grid gap-8 md:grid-cols-[auto_minmax(0,1fr)] md:gap-14">58        <div className="mx-auto w-full max-w-[420px] md:sticky md:top-20 md:w-[min(36vw,420px)] md:self-start">59          <div className="frame relative aspect-[9/16]"><HeroPlayer film={film} labels={t.player} locale={locale} loop={false} /></div>60        </div>6162        <div className="min-w-0">63          {film.series && (64            <Link href={href(locale, `/series/${film.series.slug}`)} className="kicker accent-text" style={{ "--a": film.series.accent } as React.CSSProperties}>{film.series.title}{film.episode ? ` · ${t.film.episode} ${film.episode}` : ""}</Link>65          )}66          <h1 className="display mt-3 text-[clamp(2.6rem,6vw,5rem)]">{film.title}</h1>67          {film.titleAlt !== film.title && <p className="mt-2 quote text-2xl text-[var(--color-faint)]" lang={locale === "fr" ? "en" : "fr"}>{film.titleAlt}</p>}6869          <p className="mt-5 inline-flex items-center gap-2 rounded-full border border-[var(--color-line)] px-3 py-1 text-xs text-[var(--color-muted)]">70            <span className="h-1.5 w-1.5 rounded-full bg-[var(--color-lamp)]" />{t.film.aiDisclosure}71          </p>7273          <h2 className="label mt-8">{t.film.synopsis}</h2>74          <p className="prose-film mt-2 text-lg">{film.synopsis}</p>75          {film.takeaway && (<><h2 className="label mt-8">{t.film.takeaway}</h2><p className="mt-2 max-w-[40ch] border-l border-[var(--color-lamp)] pl-4 quote text-2xl">{film.takeaway}</p></>)}7677          <div className="mt-8"><FilmActions film={film} labels={t.film} layout="row" /></div>7879          <dl className="mt-10 grid grid-cols-2 gap-x-6 gap-y-5 border-t border-[var(--color-line-soft)] pt-6 sm:grid-cols-4">80            {facts.map(([k, v]) => <div key={k}><dt className="label">{k}</dt><dd className="mt-1 text-sm">{v}</dd></div>)}81          </dl>82          {film.tags.length > 0 && <div className="mt-6 flex flex-wrap gap-2">{film.tags.map((tag) => <Link key={tag.slug} href={href(locale, `/explore?tag=${tag.slug}`)} className="chip">{tag.label}</Link>)}</div>}8384          {film.sources.length > 0 && (85            <section className="mt-10 border-t border-[var(--color-line-soft)] pt-6" aria-labelledby="sources">86              <h2 id="sources" className="display text-2xl">{t.film.factChecked}</h2>87              <p className="mt-1 text-sm text-[var(--color-faint)]">{t.film.factCheckedBody}</p>88              <ol className="mt-4 space-y-4">89                {film.sources.map((s, i) => (90                  <li key={i} className="text-sm">91                    <p className="text-[var(--color-text)]">{s.claim}</p>92                    <ul className="mt-1 space-y-0.5">{s.sources.map((x) => <li key={x.url}><a href={x.url} rel="noopener noreferrer nofollow" target="_blank" className="break-words text-[var(--color-muted)] underline decoration-[var(--color-line)] hover:text-[var(--color-accent)]">{x.title || x.url}</a></li>)}</ul>93                  </li>94                ))}95              </ol>96            </section>97          )}9899          <section className="mt-10 border-t border-[var(--color-line-soft)] pt-6" aria-labelledby="prov">100            <h2 id="prov" className="display text-2xl">{t.film.provenance}</h2>101            <p className="mt-1 max-w-[62ch] text-sm text-[var(--color-faint)]">{t.film.aiDisclosureLong}</p>102            <dl className="mt-5 grid gap-5 text-sm sm:grid-cols-2">103              <div><dt className="label">{t.film.models}</dt><dd className="mt-2 space-y-1">{(prov.models ?? []).map((m) => <p key={m.role}><span className="text-[var(--color-muted)]">{m.role}</span><br /><span className="mono">{m.provider} · {m.model}</span></p>)}</dd></div>104              <div>105                <dt className="label">{t.film.pipeline}</dt><dd className="mt-2 text-[var(--color-muted)]">{(prov.pipeline ?? []).join(" → ")}</dd>106                <dt className="label mt-4">{t.film.approval}</dt><dd className="mt-2 text-[var(--color-muted)]">{prov.humanApproval}</dd>107                {prov.manifest?.audio && (<><dt className="label mt-4">{t.film.audio}</dt><dd className="mt-2 text-[var(--color-muted)]">{t.film.narration}: {prov.manifest.audio.narration}. {t.film.bed}: {prov.manifest.audio.bed}. {t.film.music}: {prov.manifest.audio.music}.</dd></>)}108              </div>109            </dl>110            <p className="mt-6 flex flex-wrap gap-x-5 gap-y-2 text-sm">111              <a href={`/api/films/${film.slug}`} className="text-[var(--color-muted)] underline decoration-[var(--color-line)] hover:text-[var(--color-accent)]">JSON</a>112              <Link href={href(locale, `/report?film=${film.slug}`)} className="text-[var(--color-muted)] underline decoration-[var(--color-line)] hover:text-[var(--color-accent)]">{t.film.report}</Link>113            </p>114          </section>115        </div>116      </div>117118      {related.length > 0 && (119        <section className="mt-16" aria-labelledby="related">120          <h2 id="related" className="display text-3xl md:text-4xl">{t.film.related}</h2>121          <div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-4 sm:gap-4">{related.map((r) => <FilmCard key={r.film.id} film={r.film} locale={locale} note={r.reason} />)}</div>122        </section>123      )}124    </article>125  );126}127
Files main
Menu

Theme